#!/usr/bin/env python3

# Copyright (c) 2018,2020 Intel Corporation
# Copyright (c) 2022 Nordic Semiconductor ASA
# SPDX-License-Identifier: Apache-2.0

import argparse
import collections
import json
import logging
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import textwrap
import traceback
from collections.abc import Iterable
from itertools import takewhile
from pathlib import Path, PurePath

import magic
import unidiff
import yaml
from dotenv import load_dotenv
from junitparser import Error, Failure, JUnitXml, Skipped, TestCase, TestSuite
from reuse.project import Project
from reuse.report import ProjectSubsetReport
from west.manifest import Manifest, ManifestProject
from yamllint import config, linter

try:
    from yaml import CSafeLoader as SafeLoader
except ImportError:
    from yaml import SafeLoader

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import list_boards
import list_hardware
from get_maintainer import Maintainers, MaintainersError

sys.path.insert(
    0, str(Path(__file__).resolve().parents[2] / "scripts" / "dts" / "python-devicetree" / "src")
)
from devicetree import edtlib

# Let the user run this script as ./scripts/ci/check_compliance.py without
# making them set ZEPHYR_BASE.
ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE')
if ZEPHYR_BASE:
    ZEPHYR_BASE = Path(ZEPHYR_BASE)
else:
    ZEPHYR_BASE = Path(__file__).resolve().parents[2]
    # Propagate this decision to child processes.
    os.environ['ZEPHYR_BASE'] = str(ZEPHYR_BASE)

# Initialize the property names allowlist
BINDINGS_PROPERTIES_AL = None
with open(Path(__file__).parents[1] / 'bindings_properties_allowlist.yaml') as f:
    allowlist = yaml.safe_load(f.read())
    if allowlist is not None:
        BINDINGS_PROPERTIES_AL = set(allowlist)
    else:
        BINDINGS_PROPERTIES_AL = set()

logger = None


def git(*args, cwd=None, ignore_non_zero=False):
    # Helper for running a Git command. Returns the rstrip()ed stdout output.
    # Called like git("diff"). Exits with SystemError (raised by sys.exit()) on
    # errors if 'ignore_non_zero' is set to False (default: False). 'cwd' is the
    # working directory to use (default: current directory).

    git_cmd = ("git",) + args
    try:
        cp = subprocess.run(git_cmd, capture_output=True, cwd=cwd)
    except OSError as e:
        err(f"failed to run '{cmd2str(git_cmd)}': {e}")

    if not ignore_non_zero and (cp.returncode or cp.stderr):
        err(
            f"'{cmd2str(git_cmd)}' exited with status {cp.returncode} and/or "
            f"wrote to stderr.\n"
            f"==stdout==\n"
            f"{cp.stdout.decode('utf-8')}\n"
            f"==stderr==\n"
            f"{cp.stderr.decode('utf-8')}\n"
        )

    return cp.stdout.decode("utf-8").rstrip()


def get_shas(refspec):
    """
    Returns the list of Git SHAs for 'refspec'.

    :param refspec:
    :return:
    """
    return git('rev-list', f'--max-count={-1 if "." in refspec else 1}', refspec).split()


def get_files(filter=None, paths=None):
    filter_arg = (f'--diff-filter={filter}',) if filter else ()
    paths_arg = ('--', *paths) if paths else ()
    out = git('diff', '--name-only', *filter_arg, COMMIT_RANGE, *paths_arg)
    files = out.splitlines()
    for file in list(files):
        if not (GIT_TOP / file).exists():
            # Drop submodule directories from the list.
            files.remove(file)
    return files


def get_module_setting_root(root, settings_file):
    """
    Parse the Zephyr module generated settings file given by 'settings_file'
    and return all root settings defined by 'root'.
    """
    # Invoke the script directly using the Python executable since this is
    # not a module nor a pip-installed Python utility
    root_paths = []

    if os.path.exists(settings_file):
        with open(settings_file) as fp_setting_file:
            content = fp_setting_file.read()

        lines = content.strip().split('\n')
        for line in lines:
            root = root.upper()
            if line.startswith(f'"{root}_ROOT":'):
                _, root_path = line.split(":", 1)
                root_paths.append(Path(root_path.strip('"')))
    return root_paths


def get_vendor_prefixes(path, errfn=print) -> set[str]:
    vendor_prefixes = set()
    with open(path) as fp:
        for line in fp.readlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            try:
                vendor, _ = line.split("\t", 2)
                vendor_prefixes.add(vendor)
            except ValueError:
                errfn(f"Invalid line in {path}:\"{line}\".")
                errfn("Did you forget the tab character?")
    return vendor_prefixes


def zephyr_doc_detail_builder(doc_subpath: str) -> str:
    return f"See https://docs.zephyrproject.org/latest{doc_subpath} for more details."


class FmtdFailure(Failure):
    def __init__(
        self, severity, title, file, line=None, col=None, desc="", end_line=None, end_col=None
    ):
        self.severity = severity
        self.title = title
        self.file = file
        self.line = line
        self.col = col
        self.end_line = end_line
        self.end_col = end_col
        self.desc = desc
        description = f':{desc}' if desc else ''
        msg_body = desc or title

        txt = (
            f'\n{title}{description}\nFile:{file}'
            + (f'\nLine:{line}' if line else '')
            + (f'\nColumn:{col}' if col else '')
            + (f'\nEndLine:{end_line}' if end_line else '')
            + (f'\nEndColumn:{end_col}' if end_col else '')
        )
        msg = f'{file}' + (f':{line}' if line else '') + f' {msg_body}'
        typ = severity.lower()

        super().__init__(msg, typ)

        self.text = txt


class ComplianceTest:
    """
    Base class for tests. Inheriting classes should have a run() method and set
    these class variables:

    name:
      Test name

    doc:
      Link to documentation related to what's being tested

    path_hint:
      The path the test runs itself in. By default it uses the magic string
      "<git-top>" which refers to the top-level repository directory.

      This avoids running 'git' to find the top-level directory before main()
      runs (class variable assignments run when the 'class ...' statement
      runs). That avoids swallowing errors, because main() reports them to
      GitHub.

      Subclasses may override the default with a specific path or one of the
      magic strings below:
      - "<zephyr-base>" can be used to refer to the environment variable
        ZEPHYR_BASE or, when missing, the calculated base of the zephyr tree.
    """

    path_hint = "<git-top>"

    def __init__(self):
        self.case = TestCase(type(self).name, "Guidelines")
        # This is necessary because Failure can be subclassed, but since it is
        # always restored form the element tree, the subclass is lost upon
        # restoring
        self.fmtd_failures = []

    def _result(self, res, text):
        res.text = text.rstrip()
        self.case.result += [res]

    def error(self, text, msg=None, type_="error"):
        """
        Signals a problem with running the test, with message 'msg'.

        Raises an exception internally, so you do not need to put a 'return'
        after error().
        """
        err = Error(msg or f'{type(self).name} error', type_)
        self._result(err, text)

        raise EndTest

    def skip(self, text, msg=None, type_="skip"):
        """
        Signals that the test should be skipped, with message 'msg'.

        Raises an exception internally, so you do not need to put a 'return'
        after skip().
        """
        skpd = Skipped(msg or f'{type(self).name} skipped', type_)
        self._result(skpd, text)

        raise EndTest

    def failure(self, text, msg=None, type_="failure"):
        """
        Signals that the test failed, with message 'msg'. Can be called many
        times within the same test to report multiple failures.
        """
        fail = Failure(msg or f'{type(self).name} issues', type_)
        self._result(fail, text)

    def fmtd_failure(
        self, severity, title, file, line=None, col=None, desc="", end_line=None, end_col=None
    ):
        """
        Signals that the test failed, and store the information in a formatted
        standardized manner. Can be called many times within the same test to
        report multiple failures.
        """
        fail = FmtdFailure(severity, title, file, line, col, desc, end_line, end_col)
        self._result(fail, fail.text)
        self.fmtd_failures.append(fail)


class EndTest(Exception):
    """
    Raised by ComplianceTest.error()/skip() to end the test.

    Tests can raise EndTest themselves to immediately end the test, e.g. from
    within a nested function call.
    """


class CheckPatch(ComplianceTest):
    """
    Runs checkpatch and reports found issues

    """

    name = "Checkpatch"
    doc = zephyr_doc_detail_builder("/contribute/guidelines.html#coding-style")

    def run(self):
        checkpatch = ZEPHYR_BASE / 'scripts' / 'checkpatch.pl'
        if not checkpatch.exists():
            self.skip(f'{checkpatch} not found')

        # check for Perl installation on Windows
        if os.name == 'nt':
            if not shutil.which('perl'):
                err = "Perl not installed - required for checkpatch.pl. "
                err += "Please install Perl or add to PATH."
                self.failure(err)
                return
            else:
                cmd = ['perl', checkpatch]

        # Linux and MacOS
        else:
            cmd = [checkpatch]

        cmd.extend(['--mailback', '--no-tree', '-'])
        with subprocess.Popen(
            ('git', 'diff', '--no-ext-diff', COMMIT_RANGE), stdout=subprocess.PIPE, cwd=GIT_TOP
        ) as diff:
            try:
                subprocess.run(
                    cmd,
                    check=True,
                    stdin=diff.stdout,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    shell=False,
                    cwd=GIT_TOP,
                )

            except subprocess.CalledProcessError as ex:
                output = ex.output.decode("utf-8")
                regex = (
                    r'^\s*\S+:(\d+):\s*(ERROR|WARNING):(.+?):(.+)(?:\n|\r\n?)+'
                    r'^\s*#(\d+):\s*FILE:\s*(.+):(\d+):'
                )

                matches = re.findall(regex, output, re.MULTILINE)

                # add a guard here for excessive number of errors, do not try and
                # process each one of them and instead push this as one failure.
                if len(matches) > 500:
                    self.failure(output)
                    return

                for m in matches:
                    self.fmtd_failure(m[1].lower(), m[2], m[5], m[6], col=None, desc=m[3])

                # If the regex has not matched add the whole output as a failure
                if len(matches) == 0:
                    self.failure(output)


class BoardYmlCheck(ComplianceTest):
    """
    Check the board.yml files
    """

    name = "BoardYml"
    doc = "Check the board.yml file format"

    def check_board_file(self, file, vendor_prefixes):
        """Validate a single board file."""
        with open(file) as fp:
            for line_num, line in enumerate(fp.readlines(), start=1):
                if "vendor:" in line:
                    _, vnd = line.strip().split(":", 2)
                    vnd = vnd.strip()
                    if vnd not in vendor_prefixes:
                        desc = f"invalid vendor: {vnd}"
                        self.fmtd_failure("error", "BoardYml", file, line_num, desc=desc)

    def run(self):
        path = resolve_path_hint(self.path_hint)
        module_ymls = [path / "zephyr" / "module.yml", path / "zephyr" / "module.yaml"]

        vendor_prefixes = {"others"}
        # add vendor prefixes from the main zephyr repo
        vendor_prefix_file = ZEPHYR_BASE / "dts" / "bindings" / "vendor-prefixes.txt"
        vendor_prefixes |= get_vendor_prefixes(vendor_prefix_file, self.error)
        # add vendor prefixes from the current repo
        dts_root = None
        for module_yml in module_ymls:
            if module_yml.is_file():
                with module_yml.open('r', encoding='utf-8') as f:
                    meta = yaml.load(f.read(), Loader=SafeLoader)
                    section = meta.get('build', dict())
                    build_settings = section.get('settings', None)
                    if build_settings:
                        dts_root = build_settings.get('dts_root', None)

        if dts_root:
            vendor_prefix_file = Path(dts_root) / "dts" / "bindings" / "vendor-prefixes.txt"
            if vendor_prefix_file.exists():
                vendor_prefixes |= get_vendor_prefixes(vendor_prefix_file, self.error)

        for file in path.glob("**/board.yml"):
            self.check_board_file(file, vendor_prefixes)


class ClangFormatCheck(ComplianceTest):
    """
    Check if clang-format reports any issues
    """

    name = "ClangFormat"
    doc = zephyr_doc_detail_builder("/contribute/guidelines.html#clang-format")

    def _process_patch_error(self, file: str, patch: unidiff.PatchedFile):
        for hunk in patch:
            # Strip the before and after context
            before = next(i for i, v in enumerate(hunk) if str(v).startswith(('-', '+')))
            after = next(i for i, v in enumerate(reversed(hunk)) if str(v).startswith(('-', '+')))
            msg = "".join([str(line) for line in hunk[before : -after or None]])

            # show the hunk at the last line
            self.fmtd_failure(
                "notice",
                "You may want to run clang-format on this change",
                file,
                line=hunk.source_start + hunk.source_length - after,
                desc=f'\r\n{msg}',
            )

    def run(self):
        exe = f"clang-format-diff.{'exe' if platform.system() == 'Windows' else 'py'}"

        for file in get_files():
            if Path(file).suffix not in ['.c', '.h']:
                continue

            diff = subprocess.Popen(
                ('git', 'diff', '-U0', '--no-color', COMMIT_RANGE, '--', file),
                stdout=subprocess.PIPE,
                cwd=GIT_TOP,
            )
            try:
                subprocess.run(
                    (exe, '-p1'),
                    check=True,
                    stdin=diff.stdout,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    cwd=GIT_TOP,
                )

            except subprocess.CalledProcessError as ex:
                patchset = unidiff.PatchSet.from_string(ex.output, encoding="utf-8")
                for patch in patchset:
                    self._process_patch_error(file, patch)


class DevicetreeBindingsCheck(ComplianceTest):
    """
    Checks if we are introducing any unwanted properties in Devicetree Bindings.
    """

    name = "DevicetreeBindings"
    doc = zephyr_doc_detail_builder("/build/dts/bindings.html")

    def run(self, full=True):
        bindings_diff, bindings = self.get_yaml_bindings()

        # If no bindings are changed, skip this check.
        try:
            subprocess.check_call(['git', 'diff', '--quiet', COMMIT_RANGE] + bindings_diff)
            nodiff = True
        except subprocess.CalledProcessError:
            nodiff = False
        if nodiff:
            self.skip('no changes to bindings were made')

        for binding in bindings:
            self.check(binding, self.check_yaml_property_name)
            self.check(binding, self.required_false_check)

    @staticmethod
    def check(binding, callback):
        while binding is not None:
            callback(binding)
            binding = binding.child_binding

    def get_yaml_bindings(self):
        """
        Returns a list of 'dts/bindings/**/*.yaml'
        """
        from glob import glob

        BINDINGS_PATH = 'dts/bindings/'
        bindings_diff_dir, bindings = set(), []

        for file_name in get_files(filter='d'):
            if BINDINGS_PATH in file_name:
                p = file_name.partition(BINDINGS_PATH)
                bindings_diff_dir.add(os.path.join(p[0], p[1]))

        for path in bindings_diff_dir:
            yamls = glob(f'{os.fspath(path)}/**/*.yaml', recursive=True)
            bindings.extend(yamls)

        bindings = edtlib.bindings_from_paths(bindings, ignore_errors=True)
        return list(bindings_diff_dir), bindings

    def check_yaml_property_name(self, binding):
        """
        Checks if the property names in the binding file contain underscores.
        """
        for prop_name in binding.prop2specs:
            if '_' in prop_name and prop_name not in BINDINGS_PROPERTIES_AL:
                better_prop = prop_name.replace('_', '-')
                print(
                    f"Required: In '{binding.path}', "
                    f"the property '{prop_name}' "
                    f"should be renamed to '{better_prop}'."
                )
                self.failure(
                    f"{binding.path}: property '{prop_name}' contains underscores.\n"
                    f"\tUse '{better_prop}' instead unless this property name is from Linux.\n"
                    "Or another authoritative upstream source of bindings for "
                    f"compatible '{binding.compatible}'.\n"
                    "\tHint: update 'bindings_properties_allowlist.yaml' if you need to "
                    "override this check for this property."
                )

    def required_false_check(self, binding):
        raw_props = binding.raw.get('properties', {})
        for prop_name, raw_prop in raw_props.items():
            if raw_prop.get('required') is False:
                self.failure(
                    f'{binding.path}: property "{prop_name}": '
                    "'required: false' is redundant, please remove"
                )


class DevicetreeLintingCheck(ComplianceTest):
    """
    Checks if we are introducing syntax or formatting issues to devicetree files.
    """

    name = "DevicetreeLinting"
    doc = zephyr_doc_detail_builder("/contribute/style/devicetree.html")
    NPX_EXECUTABLE = "npx"

    def ensure_npx(self) -> bool:
        if not (npx_executable := shutil.which(self.NPX_EXECUTABLE)):
            return False
        try:
            self.npx_exe = npx_executable
            # --no prevents npx from fetching from registry
            subprocess.run(
                [self.npx_exe, "--prefix", "./scripts/ci", "--no", 'dts-linter', "--", "--version"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                check=True,
                text=True,
            )
            return True
        except subprocess.CalledProcessError:
            return False

    def _parse_json_output(self, cmd, cwd=None):
        """Run command and parse single JSON output with issues array"""
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            check=False,
            text=True,
            cwd=cwd or GIT_TOP,
        )

        if not result.stdout.strip():
            return None

        try:
            json_data = json.loads(result.stdout)
            return json_data
        except json.JSONDecodeError as e:
            raise RuntimeError(f"Failed to parse dts-linter JSON output: {e}") from e

    def _process_json_output(self, json_output: dict):
        if "issues" not in json_output:
            return

        cwd = json_output.get("cwd", "")
        logging.info(f"Processing issues from: {cwd}")

        for issue in json_output["issues"]:
            level = issue.get("level", "unknown")
            message = issue.get("message", "")

            if level == "info":
                logging.info(message)
            else:
                title = issue.get("title", "")
                file = issue.get("file", "")
                line = issue.get("startLine", None)
                col = issue.get("startCol", None)
                end_line = issue.get("endLine", None)
                end_col = issue.get("endCol", None)
                self.fmtd_failure(level, title, file, line, col, message, end_line, end_col)

    def run(self):
        self.npx_exe = self.NPX_EXECUTABLE
        # Get changed DTS files
        dts_files = [
            file for file in get_files(filter="d") if file.endswith((".dts", ".dtsi", ".overlay"))
        ]

        if not self.ensure_npx():
            self.skip(
                'dts-linter not installed. To run this check, '
                'install Node.js and then run [npm --prefix ./scripts/ci ci] command inside '
                'ZEPHYR_BASE'
            )
        if not dts_files:
            self.skip('No DTS')

        temp_patch_files = []
        batch_size = 500

        for i in range(0, len(dts_files), batch_size):
            batch = dts_files[i : i + batch_size]

            # use a temporary file for each batch
            temp_patch = f"dts_linter_{i}.patch"
            temp_patch_files.append(temp_patch)

            cmd = [
                self.npx_exe,
                "--prefix",
                "./scripts/ci",
                "--no",
                "dts-linter",
                "--",
                "--outputFormat",
                "json",
                "--format",
                "--patchFile",
                temp_patch,
            ]
            for file in batch:
                cmd.extend(["--file", file])

            try:
                json_output = self._parse_json_output(cmd)
                if json_output:
                    self._process_json_output(json_output)

            except subprocess.CalledProcessError as ex:
                stderr_output = ex.stderr if ex.stderr else ""
                if stderr_output.strip():
                    self.failure(f"dts-linter found issues:\n{stderr_output}")
                else:
                    err = "dts-linter failed with no output. "
                    err += "Make sure you install Node.js and then run "
                    err += "[npm --prefix ./scripts/ci ci] inside ZEPHYR_BASE"
                    self.failure(err)
            except RuntimeError as ex:
                self.failure(f"{ex}")

        # merge all temp patch files into one
        with open("dts_linter.patch", "wb") as final_patch:
            for patch in temp_patch_files:
                with open(patch, "rb") as f:
                    shutil.copyfileobj(f, final_patch)

        # cleanup
        for patch in temp_patch_files:
            os.remove(patch)


class KconfigCheck(ComplianceTest):
    """
    Checks is we are introducing any new warnings/errors with Kconfig,
    for example using undefined Kconfig variables.
    """

    name = "Kconfig"
    doc = zephyr_doc_detail_builder("/build/kconfig/tips.html")

    # Top-level Kconfig file. The path can be relative to srctree (ZEPHYR_BASE).
    FILENAME = "Kconfig"

    # Kconfig symbol prefix/namespace.
    CONFIG_ = "CONFIG_"

    def run(self):
        kconf = self.parse_kconfig()

        self.check_top_menu_not_too_long(kconf)
        self.check_no_pointless_menuconfigs(kconf)
        self.check_no_undef_within_kconfig(kconf)
        self.check_no_redefined_in_defconfig(kconf)
        self.check_no_enable_in_boolean_prompt(kconf)
        self.check_soc_name_sync(kconf)
        self.check_no_undef_outside_kconfig(kconf)
        self.check_disallowed_defconfigs(kconf)

    def get_modules(self, _module_dirs_file, modules_file, sysbuild_modules_file, settings_file):
        """
        Get a list of modules and put them in a file that is parsed by
        Kconfig

        This is needed to complete Kconfig sanity tests.

        """
        # Invoke the script directly using the Python executable since this is
        # not a module nor a pip-installed Python utility
        zephyr_module_path = ZEPHYR_BASE / "scripts" / "zephyr_module.py"
        cmd = [
            sys.executable,
            zephyr_module_path,
            '--kconfig-out',
            modules_file,
            '--sysbuild-kconfig-out',
            sysbuild_modules_file,
            '--settings-out',
            settings_file,
        ]
        try:
            subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        except subprocess.CalledProcessError as ex:
            self.error(ex.output.decode("utf-8"))

        modules_dir = ZEPHYR_BASE / 'modules'
        modules = [name for name in os.listdir(modules_dir) if modules_dir / name / 'Kconfig']

        with open(modules_file) as fp_module_file:
            content = fp_module_file.read()

        with open(modules_file, 'w') as fp_module_file:
            for module in modules:
                fp_module_file.write(
                    "ZEPHYR_{}_KCONFIG = {}\n".format(
                        re.sub('[^a-zA-Z0-9]', '_', module).upper(),
                        modules_dir / module / 'Kconfig',
                    )
                )
            fp_module_file.write(content)

    def get_kconfig_dts(self, kconfig_dts_file, settings_file):
        """
        Generate the Kconfig.dts using dts/bindings as the source.

        This is needed to complete Kconfig compliance tests.

        """
        # Invoke the script directly using the Python executable since this is
        # not a module nor a pip-installed Python utility
        zephyr_drv_kconfig_path = ZEPHYR_BASE / "scripts" / "dts" / "gen_driver_kconfig_dts.py"
        binding_paths = []
        binding_paths.append(ZEPHYR_BASE / "dts" / "bindings")

        dts_root_paths = get_module_setting_root('dts', settings_file)
        for p in dts_root_paths:
            binding_paths.append(p / "dts" / "bindings")

        cmd = [
            sys.executable,
            zephyr_drv_kconfig_path,
            '--kconfig-out',
            kconfig_dts_file,
            '--bindings-dirs',
        ]
        for binding_path in binding_paths:
            cmd.append(binding_path)
        try:
            subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        except subprocess.CalledProcessError as ex:
            self.error(ex.output.decode("utf-8"))

    def get_v2_model(self, kconfig_dir, settings_file):
        """
        Get lists of v2 boards and SoCs and put them in a file that is parsed by
        Kconfig

        This is needed to complete Kconfig sanity tests.
        """
        os.environ['HWM_SCHEME'] = 'v2'
        os.environ["KCONFIG_BOARD_DIR"] = os.path.join(kconfig_dir, 'boards')

        os.makedirs(os.path.join(kconfig_dir, 'boards'), exist_ok=True)
        os.makedirs(os.path.join(kconfig_dir, 'soc'), exist_ok=True)
        os.makedirs(os.path.join(kconfig_dir, 'arch'), exist_ok=True)

        kconfig_file = os.path.join(kconfig_dir, 'boards', 'Kconfig')
        kconfig_boards_file = os.path.join(kconfig_dir, 'boards', 'Kconfig.boards')
        kconfig_sysbuild_file = os.path.join(kconfig_dir, 'boards', 'Kconfig.sysbuild')
        kconfig_defconfig_file = os.path.join(kconfig_dir, 'boards', 'Kconfig.defconfig')

        board_roots = get_module_setting_root('board', settings_file)
        board_roots.insert(0, ZEPHYR_BASE)
        soc_roots = get_module_setting_root('soc', settings_file)
        soc_roots.insert(0, ZEPHYR_BASE)
        root_args = argparse.Namespace(
            **{'board_roots': board_roots, 'soc_roots': soc_roots, 'board': None, 'board_dir': []}
        )
        v2_boards = list_boards.find_v2_boards(root_args).values()

        with open(kconfig_defconfig_file, 'w') as fp:
            for board in v2_boards:
                for board_dir in board.directories:
                    fp.write('osource "' + (board_dir / 'Kconfig.defconfig').as_posix() + '"\n')

        with open(kconfig_sysbuild_file, 'w') as fp:
            for board in v2_boards:
                for board_dir in board.directories:
                    fp.write('osource "' + (board_dir / 'Kconfig.sysbuild').as_posix() + '"\n')

        with open(kconfig_boards_file, 'w') as fp:
            for board in v2_boards:
                board_str = 'BOARD_' + re.sub(r"[^a-zA-Z0-9_]", "_", board.name).upper()
                fp.write('config  ' + board_str + '\n')
                fp.write('\t bool\n')
                for qualifier in list_boards.board_v2_qualifiers(board):
                    board_str = (
                        'BOARD_' + board.name + '_' + re.sub(r"[^a-zA-Z0-9_]", "_", qualifier)
                    ).upper()
                    fp.write('config  ' + board_str + '\n')
                    fp.write('\t bool\n')
                for board_dir in board.directories:
                    fp.write(
                        'source "' + (board_dir / ('Kconfig.' + board.name)).as_posix() + '"\n'
                    )

        with open(kconfig_file, 'w') as fp:
            for board in v2_boards:
                for board_dir in board.directories:
                    fp.write('osource "' + (board_dir / 'Kconfig').as_posix() + '"\n')

        kconfig_defconfig_file = os.path.join(kconfig_dir, 'soc', 'Kconfig.defconfig')
        kconfig_sysbuild_file = os.path.join(kconfig_dir, 'soc', 'Kconfig.sysbuild')
        kconfig_soc_file = os.path.join(kconfig_dir, 'soc', 'Kconfig.soc')
        kconfig_file = os.path.join(kconfig_dir, 'soc', 'Kconfig')

        root_args = argparse.Namespace(**{'soc_roots': soc_roots})
        v2_systems = list_hardware.find_v2_systems(root_args)

        soc_folders = {folder for soc in v2_systems.get_socs() for folder in soc.folder}
        with open(kconfig_defconfig_file, 'w') as fp:
            for folder in soc_folders:
                fp.write('osource "' + (Path(folder) / 'Kconfig.defconfig').as_posix() + '"\n')

        with open(kconfig_sysbuild_file, 'w') as fp:
            for folder in soc_folders:
                fp.write('osource "' + (Path(folder) / 'Kconfig.sysbuild').as_posix() + '"\n')

        with open(kconfig_soc_file, 'w') as fp:
            for folder in soc_folders:
                fp.write('source "' + (Path(folder) / 'Kconfig.soc').as_posix() + '"\n')

        with open(kconfig_file, 'w') as fp:
            for folder in soc_folders:
                fp.write('source "' + (Path(folder) / 'Kconfig').as_posix() + '"\n')

        kconfig_file = os.path.join(kconfig_dir, 'arch', 'Kconfig')

        root_args = argparse.Namespace(**{'arch_roots': [ZEPHYR_BASE], 'arch': None})
        v2_archs = list_hardware.find_v2_archs(root_args)

        with open(kconfig_file, 'w') as fp:
            for arch in v2_archs['archs']:
                fp.write('source "' + (Path(arch['path']) / 'Kconfig').as_posix() + '"\n')

    def parse_kconfig(self):
        """
        Returns a kconfiglib.Kconfig object for the Kconfig files. We reuse
        this object for all tests to avoid having to reparse for each test.
        """
        # Put the Kconfiglib path first to make sure no local Kconfiglib version is
        # used
        kconfig_path = ZEPHYR_BASE / "scripts" / "kconfig"
        if not kconfig_path.exists():
            self.error(kconfig_path + " not found")

        kconfiglib_dir = tempfile.mkdtemp(prefix="kconfiglib_")

        sys.path.insert(0, str(kconfig_path))
        # Import globally so that e.g. kconfiglib.Symbol can be referenced in
        # tests
        global kconfiglib
        import kconfiglib

        # Look up Kconfig files relative to ZEPHYR_BASE
        os.environ["srctree"] = str(ZEPHYR_BASE)  # noqa: SIM112

        # Parse the entire Kconfig tree, to make sure we see all symbols
        os.environ["SOC_DIR"] = "soc/"
        os.environ["ARCH_DIR"] = "arch/"
        os.environ["BOARD"] = "boards"
        os.environ["ARCH"] = "*"
        os.environ["KCONFIG_BINARY_DIR"] = kconfiglib_dir
        os.environ['DEVICETREE_CONF'] = "dummy"
        os.environ['TOOLCHAIN_HAS_NEWLIB'] = "y"
        kconfig_env_file = os.path.join(kconfiglib_dir, "kconfig_module_dirs.env")

        # Older name for DEVICETREE_CONF, for compatibility with older Zephyr
        # versions that don't have the renaming
        os.environ["GENERATED_DTS_BOARD_CONF"] = "dummy"

        # For multi repo support
        self.get_modules(
            kconfig_env_file,
            os.path.join(kconfiglib_dir, "Kconfig.modules"),
            os.path.join(kconfiglib_dir, "Kconfig.sysbuild.modules"),
            os.path.join(kconfiglib_dir, "settings_file.txt"),
        )
        # For Kconfig.dts support
        self.get_kconfig_dts(
            os.path.join(kconfiglib_dir, "Kconfig.dts"),
            os.path.join(kconfiglib_dir, "settings_file.txt"),
        )
        # For hardware model support (board, soc, arch)
        self.get_v2_model(kconfiglib_dir, os.path.join(kconfiglib_dir, "settings_file.txt"))

        # Tells Kconfiglib to generate warnings for all references to undefined
        # symbols within Kconfig files
        os.environ["KCONFIG_WARN_UNDEF"] = "y"

        load_dotenv(kconfig_env_file)

        try:
            # Note this will both print warnings to stderr _and_ return
            # them: so some warnings might get printed
            # twice. "warn_to_stderr=False" could unfortunately cause
            # some (other) warnings to never be printed.
            return kconfiglib.Kconfig(filename=self.FILENAME)
        except kconfiglib.KconfigError as e:
            self.failure(str(e))
            raise EndTest from e
        finally:
            # Clean up the temporary directory
            shutil.rmtree(kconfiglib_dir)

    def module_kconfigs(self, regex):
        manifest = Manifest.from_file()
        kconfigs = ""

        # Use hard coded paths for Zephyr for tests, samples and ext. module root
        tmp_output = git(
            "grep",
            "-I",
            "-h",
            "--perl-regexp",
            regex,
            "--",
            ":tests",
            ":samples",
            ":modules",
            cwd=ZEPHYR_BASE,
            ignore_non_zero=True,
        )

        if len(tmp_output) > 0:
            kconfigs += tmp_output + "\n"

        for project in manifest.get_projects([]):
            if not manifest.is_active(project):
                continue

            if not project.is_cloned():
                continue

            module_path = PurePath(project.abspath)
            module_yml = module_path.joinpath('zephyr/module.yml')

            if not Path(module_yml).is_file():
                module_yml = module_path.joinpath('zephyr/module.yaml')

            if Path(module_yml).is_file():
                dirs = []

                with Path(module_yml).open('r', encoding='utf-8') as f:
                    meta = yaml.load(f.read(), Loader=SafeLoader)

                for folder_type in ['samples', 'tests']:
                    if folder_type in meta:
                        for path_ext in meta[folder_type]:
                            path_full = module_path.joinpath(path_ext)

                            if Path(path_full).is_dir():
                                dirs.append(":" + path_ext)

                # Add ext. module root, if one is defined
                if (
                    'build' in meta
                    and 'settings' in meta['build']
                    and 'module_ext_root' in meta['build']['settings']
                ):
                    path_full = module_path.joinpath(meta['build']['settings']['module_ext_root'])

                    if Path(path_full).is_dir():
                        dirs.append(":" + meta['build']['settings']['module_ext_root'])

                if len(dirs) > 0:
                    tmp_output = git(
                        "grep",
                        "-I",
                        "-h",
                        "--perl-regexp",
                        regex,
                        "--",
                        *dirs,
                        cwd=module_path,
                        ignore_non_zero=True,
                    )

                    if len(tmp_output) > 0:
                        kconfigs += tmp_output + "\n"

        return kconfigs

    def get_logging_syms(self, kconf):
        # Returns a set() with the names of the Kconfig symbols generated with
        # logging template in samples/tests folders. The Kconfig symbols doesn't
        # include `CONFIG_` and for each module declared there is one symbol
        # per suffix created.

        suffixes = [
            "_LOG_LEVEL",
            "_LOG_LEVEL_DBG",
            "_LOG_LEVEL_ERR",
            "_LOG_LEVEL_INF",
            "_LOG_LEVEL_WRN",
            "_LOG_LEVEL_OFF",
            "_LOG_LEVEL_INHERIT",
            "_LOG_LEVEL_DEFAULT",
        ]

        # Warning: Needs to work with both --perl-regexp and the 're' module.
        regex = r"^\s*(?:module\s*=\s*)([A-Z0-9_]+)\s*(?:#|$)"

        # Grep samples/ and tests/ for symbol definitions in all modules
        grep_stdout = self.module_kconfigs(regex)

        names = re.findall(regex, grep_stdout, re.MULTILINE)

        kconf_syms = []
        for name in names:
            for suffix in suffixes:
                kconf_syms.append(f"{name}{suffix}")

        return set(kconf_syms)

    def module_disallowed_check(self, module_path, type, folder, meta, regex):
        # Returns a list with lines from git grep which includes Kconfigs from defconfig files
        entry = type + '_root'
        git_folder = ":" + folder

        if entry in meta['build']['settings']:
            tmp_path = module_path.joinpath(meta['build']['settings'][entry])

            if Path(tmp_path.joinpath(folder)).is_dir():
                tmp_output = git(
                    "grep",
                    "--line-number",
                    "-I",
                    "--null",
                    "--perl-regexp",
                    regex,
                    "--",
                    git_folder,
                    cwd=tmp_path,
                    ignore_non_zero=True,
                )

                if len(tmp_output) > 0:
                    return tmp_output.splitlines()
        return []

    def check_disallowed_defconfigs(self, kconf):
        """
        Checks that there are no disallowed Kconfigs used in board/SoC defconfig files
        """
        # Grep for symbol references.
        #
        # Example output line for a reference to CONFIG_FOO at line 17 of
        # foo/bar.c:
        #
        #   foo/bar.c<null>17<null>#ifdef CONFIG_FOO
        #
        # 'git grep --only-matching' would get rid of the surrounding context
        # ('#ifdef '), but it was added fairly recently (second half of 2018),
        # so we extract the references from each line ourselves instead.
        #
        # The regex uses word boundaries (\b) to isolate the reference, and
        # negative lookahead to automatically allowlist the following:
        #
        #  - ##, for token pasting (CONFIG_FOO_##X)
        #
        #  - $, e.g. for CMake variable expansion (CONFIG_FOO_${VAR})
        #
        #  - @, e.g. for CMakes's configure_file() (CONFIG_FOO_@VAR@)
        #
        #  - {, e.g. for Python scripts ("CONFIG_FOO_{}_BAR".format(...)")
        #
        #  - *, meant for comments like '#endif /* CONFIG_FOO_* */

        disallowed_symbols = {
            "PINCTRL": "Drivers requiring PINCTRL must SELECT it instead.",
            "BOARD_EARLY_INIT_HOOK": "Boards requiring hooks must SELECT them instead.",
            "BOARD_LATE_INIT_HOOK": "Boards requiring hooks must SELECT them instead.",
        }

        disallowed_regex = "(" + "|".join(disallowed_symbols.keys()) + ")$"

        # Warning: Needs to work with both --perl-regexp and the 're' module
        # Windows
        if os.name == 'nt':
            # Remove word boundaries on Windows implementation
            regex_boards = r"CONFIG_[A-Z0-9_]+(?!\s*##|[$@{(.*])"
            regex_socs = r"config[ \t]+[A-Z0-9_]+"
        else:
            regex_boards = r"\bCONFIG_[A-Z0-9_]+\b(?!\s*##|[$@{(.*])"
            regex_socs = r"\bconfig\s+[A-Z0-9_]+$"

        grep_stdout_boards = git(
            "grep",
            "--line-number",
            "-I",
            "--null",
            "--perl-regexp",
            regex_boards,
            "--",
            ":boards",
            cwd=ZEPHYR_BASE,
        ).splitlines()
        grep_stdout_socs = git(
            "grep",
            "--line-number",
            "-I",
            "--null",
            "--perl-regexp",
            regex_socs,
            "--",
            ":soc",
            cwd=ZEPHYR_BASE,
        ).splitlines()

        manifest = Manifest.from_file()
        for project in manifest.get_projects([]):
            if not manifest.is_active(project):
                continue

            if not project.is_cloned():
                continue

            module_path = PurePath(project.abspath)
            module_yml = module_path.joinpath('zephyr/module.yml')

            if not Path(module_yml).is_file():
                module_yml = module_path.joinpath('zephyr/module.yaml')

            if Path(module_yml).is_file():
                with Path(module_yml).open('r', encoding='utf-8') as f:
                    meta = yaml.load(f.read(), Loader=SafeLoader)

                    if 'build' in meta and 'settings' in meta['build']:
                        grep_stdout_boards.extend(
                            self.module_disallowed_check(
                                module_path, 'board', 'boards', meta, regex_boards
                            )
                        )
                        grep_stdout_socs.extend(
                            self.module_disallowed_check(
                                module_path, 'soc', 'soc', meta, regex_socs
                            )
                        )

        # Board processing
        # splitlines() supports various line terminators
        for grep_line in grep_stdout_boards:
            path, lineno, line = grep_line.split("\0")

            # Extract symbol references (might be more than one) within the line
            for sym_name in re.findall(regex_boards, line):
                sym_name = sym_name[len("CONFIG_") :]
                # Only check in Kconfig fragment files, references might exist in documentation
                if re.match(disallowed_regex, sym_name) and (
                    path[-len("conf") :] == "conf" or path[-len("defconfig") :] == "defconfig"
                ):
                    reason = disallowed_symbols.get(sym_name)
                    self.fmtd_failure(
                        "error",
                        "BoardDisallowedKconfigs",
                        path,
                        lineno,
                        desc=f"""
Found disallowed Kconfig symbol in board Kconfig files: CONFIG_{sym_name:35}
{reason}
""",
                    )

        # SoCs processing
        # splitlines() supports various line terminators
        for grep_line in grep_stdout_socs:
            path, lineno, line = grep_line.split("\0")

            # Extract symbol references (might be more than one) within the line
            for sym_name in re.findall(regex_socs, line):
                sym_name = sym_name[len("config") :].strip()
                # Only check in Kconfig defconfig files
                if re.match(disallowed_regex, sym_name) and "defconfig" in path:
                    reason = disallowed_symbols.get(sym_name, "Unknown reason")
                    self.fmtd_failure(
                        "error",
                        "SoCDisallowedKconfigs",
                        path,
                        lineno,
                        desc=f"""
Found disallowed Kconfig symbol in SoC Kconfig files: {sym_name:35}
{reason}
""",
                    )

    def get_defined_syms(self, kconf):
        # Returns a set() with the names of all defined Kconfig symbols (with no
        # 'CONFIG_' prefix). This is complicated by samples and tests defining
        # their own Kconfig trees. For those, just grep for 'config FOO' to find
        # definitions. Doing it "properly" with Kconfiglib is still useful for
        # the main tree, because some symbols are defined using preprocessor
        # macros.

        # Warning: Needs to work with both --perl-regexp and the 're' module.
        # (?:...) is a non-capturing group.
        regex = r"^\s*(?:menu)?config\s*([A-Z0-9_]+)\s*(?:#|$)"

        # Grep samples/ and tests/ for symbol definitions in all modules
        grep_stdout = self.module_kconfigs(regex)

        # Generate combined list of configs and choices from the main Kconfig tree.
        kconf_syms = kconf.unique_defined_syms + kconf.unique_choices

        # Symbols from the main Kconfig tree + grepped definitions from samples
        # and tests
        return set(
            [sym.name for sym in kconf_syms] + re.findall(regex, grep_stdout, re.MULTILINE)
        ).union(self.get_logging_syms(kconf))

    def check_top_menu_not_too_long(self, kconf):
        """
        Checks that there aren't too many items in the top-level menu (which
        might be a sign that stuff accidentally got added there)
        """
        max_top_items = 50

        n_top_items = 0
        node = kconf.top_node.list
        while node:
            # Only count items with prompts. Other items will never be
            # shown in the menuconfig (outside show-all mode).
            if node.prompt:
                n_top_items += 1
            node = node.next

        if n_top_items > max_top_items:
            self.failure(f"""
Expected no more than {max_top_items} potentially visible items (items with
prompts) in the top-level Kconfig menu, found {n_top_items} items. If you're
deliberately adding new entries, then bump the 'max_top_items' variable in
{__file__}.""")

    def check_no_redefined_in_defconfig(self, kconf):
        # Checks that no symbols are (re)defined in defconfigs.

        for node in kconf.node_iter():
            # 'kconfiglib' is global
            # pylint: disable=undefined-variable
            if "defconfig" in node.filename and (node.prompt or node.help):
                name = (
                    node.item.name
                    if node.item not in (kconfiglib.MENU, kconfiglib.COMMENT)
                    else str(node)
                )
                self.failure(f"""
Kconfig node '{name}' found with prompt or help in {node.filename}.
Options must not be defined in defconfig files.
""")
                continue

    def check_no_enable_in_boolean_prompt(self, kconf):
        # Checks that boolean's prompt does not start with "Enable...".

        for node in kconf.node_iter():
            # skip Kconfig nodes not in-tree (will present an absolute path)
            if os.path.isabs(node.filename):
                continue

            # 'kconfiglib' is global
            # pylint: disable=undefined-variable

            # only process boolean symbols with a prompt
            if (
                not isinstance(node.item, kconfiglib.Symbol)
                or node.item.type != kconfiglib.BOOL
                or not node.prompt
                or not node.prompt[0]
            ):
                continue

            if re.match(r"^[Ee]nable.*", node.prompt[0]):
                self.failure(f"""
Boolean option '{node.item.name}' prompt must not start with 'Enable...'. Please
check Kconfig guidelines.
""")
                continue

    def check_no_pointless_menuconfigs(self, kconf):
        # Checks that there are no pointless 'menuconfig' symbols without
        # children in the Kconfig files

        bad_mconfs = []
        for node in kconf.node_iter():
            # 'kconfiglib' is global
            # pylint: disable=undefined-variable

            # Avoid flagging empty regular menus and choices, in case people do
            # something with 'osource' (could happen for 'menuconfig' symbols
            # too, though it's less likely)
            if node.is_menuconfig and not node.list and isinstance(node.item, kconfiglib.Symbol):
                bad_mconfs.append(node)

        if bad_mconfs:
            self.failure(
                """\
Found pointless 'menuconfig' symbols without children. Use regular 'config'
symbols instead. See
https://docs.zephyrproject.org/latest/build/kconfig/tips.html#menuconfig-symbols.

"""
                + "\n".join(
                    f"{node.item.name:35} {node.filename}:{node.linenr}" for node in bad_mconfs
                )
            )

    def check_no_undef_within_kconfig(self, kconf):
        """
        Checks that there are no references to undefined Kconfig symbols within
        the Kconfig files
        """
        undef_ref_warnings = "\n\n\n".join(
            warning for warning in kconf.warnings if "undefined symbol" in warning
        )

        if undef_ref_warnings:
            self.failure(f"Undefined Kconfig symbols:\n\n {undef_ref_warnings}")

    def check_soc_name_sync(self, kconf):
        root_args = argparse.Namespace(**{'soc_roots': [ZEPHYR_BASE]})
        v2_systems = list_hardware.find_v2_systems(root_args)

        soc_names = {soc.name for soc in v2_systems.get_socs()}

        soc_kconfig_names = set()
        for node in kconf.node_iter():
            # 'kconfiglib' is global
            # pylint: disable=undefined-variable
            if isinstance(node.item, kconfiglib.Symbol) and node.item.name == "SOC":
                n = node.item
                for d in n.defaults:
                    soc_kconfig_names.add(d[0].name)

        soc_name_warnings = []
        for name in soc_names:
            if name not in soc_kconfig_names:
                soc_name_warnings.append(f"soc name: {name} not found in CONFIG_SOC defaults.")

        if soc_name_warnings:
            soc_name_warning_str = '\n'.join(soc_name_warnings)
            self.failure(f'''
Missing SoC names or CONFIG_SOC vs soc.yml out of sync:

{soc_name_warning_str}
''')

    def check_no_undef_outside_kconfig(self, kconf):
        """
        Checks that there are no references to undefined Kconfig symbols
        outside Kconfig files (any CONFIG_FOO where no FOO symbol exists)
        """
        # Grep for symbol references.
        #
        # Example output line for a reference to CONFIG_FOO at line 17 of
        # foo/bar.c:
        #
        #   foo/bar.c<null>17<null>#ifdef CONFIG_FOO
        #
        # 'git grep --only-matching' would get rid of the surrounding context
        # ('#ifdef '), but it was added fairly recently (second half of 2018),
        # so we extract the references from each line ourselves instead.
        #
        # The regex uses word boundaries (\b) to isolate the reference, and
        # negative lookahead to automatically allowlist the following:
        #
        #  - ##, for token pasting (CONFIG_FOO_##X)
        #
        #  - $, e.g. for CMake variable expansion (CONFIG_FOO_${VAR})
        #
        #  - @, e.g. for CMakes's configure_file() (CONFIG_FOO_@VAR@)
        #
        #  - {, e.g. for Python scripts ("CONFIG_FOO_{}_BAR".format(...)")
        #
        #  - *, meant for comments like '#endif /* CONFIG_FOO_* */

        defined_syms = self.get_defined_syms(kconf)

        # Maps each undefined symbol to a list <filename>:<linenr> strings
        undef_to_locs = collections.defaultdict(list)

        # Warning: Needs to work with both --perl-regexp and the 're' module
        regex = r"\b" + self.CONFIG_ + r"[A-Z0-9_]+\b(?!\s*##|[$@{(.*])"

        # Skip doc/releases and doc/security/vulnerabilities.rst, which often
        # reference removed symbols
        grep_stdout = git(
            "grep",
            "--line-number",
            "-I",
            "--null",
            "--perl-regexp",
            regex,
            "--",
            ":!/doc/releases",
            ":!/doc/security/vulnerabilities.rst",
            cwd=GIT_TOP,
        )

        # splitlines() supports various line terminators
        for grep_line in grep_stdout.splitlines():
            path, lineno, line = grep_line.split("\0")

            # Extract symbol references (might be more than one) within the
            # line
            for sym_name in re.findall(regex, line):
                sym_name = sym_name[len(self.CONFIG_) :]  # Strip CONFIG_
                if (
                    sym_name not in defined_syms
                    and sym_name not in self.UNDEF_KCONFIG_ALLOWLIST
                    and not (sym_name.endswith("_MODULE") and sym_name[:-7] in defined_syms)
                    and not sym_name.startswith("BOARD_REVISION_")
                    and not (sym_name.startswith("DT_HAS_") and sym_name.endswith("_ENABLED"))
                ):
                    undef_to_locs[sym_name].append(f"{path}:{lineno}")

        if not undef_to_locs:
            return

        # String that describes all referenced but undefined Kconfig symbols,
        # in alphabetical order, along with the locations where they're
        # referenced. Example:
        #
        #   CONFIG_ALSO_MISSING    arch/xtensa/core/fatal.c:273
        #   CONFIG_MISSING         arch/xtensa/core/fatal.c:264, subsys/fb/cfb.c:20
        undef_desc = "\n".join(
            f"{self.CONFIG_}{sym_name:35} {', '.join(locs)}"
            for sym_name, locs in sorted(undef_to_locs.items())
        )

        self.failure(f"""
Found references to undefined Kconfig symbols. If any of these are false
positives, then add them to UNDEF_KCONFIG_ALLOWLIST in {__file__}.

If the reference is for a comment like /* CONFIG_FOO_* */ (or
/* CONFIG_FOO_*_... */), then please use exactly that form (with the '*'). The
CI check knows not to flag it.

More generally, a reference followed by $, @, {{, (, ., *, or ## will never be
flagged.

{undef_desc}""")

    # Many of these are symbols used as examples. Note that the list is sorted
    # alphabetically, and skips the CONFIG_ prefix.
    UNDEF_KCONFIG_ALLOWLIST = {
        # zephyr-keep-sorted-start re(^\s+")
        "ALSO_MISSING",
        "APP_LINK_WITH_",
        # Application log level is not detected correctly as
        # the option is defined using a template, so it can't
        # be grepped
        "APP_LOG_LEVEL",
        "APP_LOG_LEVEL_DBG",
        # The ARMCLANG_STD_LIBC is defined in the
        # toolchain Kconfig which is sourced based on
        # Zephyr toolchain variant and therefore not
        # visible to compliance.
        "ARMCLANG_STD_LIBC",
        "BINDESC_",  # Used in documentation as a prefix
        "BOARD_",  # Used as regex in scripts/utils/board_v1_to_v2.py
        "BOARD_MPS2_AN521_CPUTEST",  # Used for board and SoC extension feature tests
        "BOARD_NATIVE_SIM_NATIVE_64_TWO",  # Used for board and SoC extension feature tests
        "BOARD_NATIVE_SIM_NATIVE_ONE",  # Used for board and SoC extension feature tests
        "BOARD_UNIT_TESTING",  # Used for tests/unit
        "BOOT_DIRECT_XIP",  # Used in sysbuild for MCUboot configuration
        "BOOT_DIRECT_XIP_REVERT",  # Used in sysbuild for MCUboot configuration
        "BOOT_ENCRYPTION_KEY_FILE",  # Used in sysbuild
        "BOOT_ENCRYPT_ALG_AES_128",  # Used in sysbuild
        "BOOT_ENCRYPT_ALG_AES_256",  # Used in sysbuild
        "BOOT_ENCRYPT_IMAGE",  # Used in sysbuild
        "BOOT_FIRMWARE_LOADER",  # Used in sysbuild for MCUboot configuration
        "BOOT_FIRMWARE_LOADER_BOOT_MODE",  # Used in sysbuild for MCUboot configuration
        "BOOT_IMAGE_EXECUTABLE_RAM_SIZE",  # MCUboot setting
        "BOOT_IMAGE_EXECUTABLE_RAM_START",  # MCUboot setting
        "BOOT_MAX_IMG_SECTORS_AUTO",  # Used in sysbuild
        "BOOT_RAM_LOAD",  # Used in sysbuild for MCUboot configuration
        "BOOT_RAM_LOAD_REVERT",  # Used in sysbuild for MCUboot configuration
        "BOOT_SERIAL_BOOT_MODE",  # Used in (sysbuild-based) test/documentation
        "BOOT_SERIAL_CDC_ACM",  # Used in (sysbuild-based) test
        "BOOT_SERIAL_ENTRANCE_GPIO",  # Used in (sysbuild-based) test
        "BOOT_SERIAL_IMG_GRP_HASH",  # Used in documentation
        "BOOT_SERIAL_UART",  # Used in (sysbuild-based) test
        "BOOT_SHARE_BACKEND_RETENTION",  # Used in Kconfig text
        "BOOT_SHARE_DATA",  # Used in Kconfig text
        "BOOT_SHARE_DATA_BOOTINFO",  # Used in (sysbuild-based) test
        "BOOT_SIGNATURE_KEY_FILE",  # MCUboot setting used by sysbuild
        "BOOT_SIGNATURE_TYPE_ECDSA_P256",  # MCUboot setting used by sysbuild
        "BOOT_SIGNATURE_TYPE_ED25519",  # MCUboot setting used by sysbuild
        "BOOT_SIGNATURE_TYPE_NONE",  # MCUboot setting used by sysbuild
        "BOOT_SIGNATURE_TYPE_RSA",  # MCUboot setting used by sysbuild
        "BOOT_SWAP_USING_MOVE",  # Used in sysbuild for MCUboot configuration
        "BOOT_SWAP_USING_OFFSET",  # Used in sysbuild for MCUboot configuration
        "BOOT_SWAP_USING_SCRATCH",  # Used in sysbuild for MCUboot configuration
        # Used in example adjusting MCUboot config, but
        # symbol is defined in MCUboot itself.
        "BOOT_UPGRADE_ONLY",
        "BOOT_VALIDATE_SLOT0",  # Used in (sysbuild-based) test
        "BOOT_WATCHDOG_FEED",  # Used in (sysbuild-based) test
        "BT_6LOWPAN",  # Defined in Linux, mentioned in docs
        "CDC_ACM_PORT_NAME_",
        "CHRE",  # Optional module
        "CHRE_LOG_LEVEL_DBG",  # Optional module
        "CLOCK_STM32_SYSCLK_SRC_",
        "CMD_CACHE",  # Defined in U-Boot, mentioned in docs
        "CMU",
        "COMPILER_RT_RTLIB",
        "CRC",  # Used in TI CC13x2 / CC26x2 SDK comment
        "DEEP_SLEEP",  # #defined by RV32M1 in ext/
        "DESCRIPTION",
        "ERR",
        "ESP_DIF_LIBRARY",  # Referenced in CMake comment
        "EXPERIMENTAL",
        "EXTRA_FIRMWARE_DIR",  # Linux, in boards/xtensa/intel_adsp_cavs25/doc
        "FFT",  # Used as an example in cmake/extensions.cmake
        "FLAG",  # Used as an example
        "FOO",
        "FOO_LOG_LEVEL",
        "FOO_SETTING_1",
        "FOO_SETTING_2",
        "HEAP_MEM_POOL_ADD_SIZE_",  # Used as an option matching prefix
        "HUGETLBFS",  # Linux, in boards/xtensa/intel_adsp_cavs25/doc
        "IAR_BUFFERED_WRITE",
        "IAR_DATA_INIT",
        "IAR_LIBCPP",
        "IAR_SEMIHOSTING",
        "IAR_ZEPHYR_INIT",
        # Used in ICMsg tests for intercompatibility
        # with older versions of the ICMsg.
        "IPC_SERVICE_ICMSG_BOND_NOTIFY_REPEAT_TO_MS",
        "LIBGCC_RTLIB",
        "LLVM_USE_LD",  # Both LLVM_USE_* are in cmake/toolchain/llvm/Kconfig
        # which are only included if LLVM is selected but
        # not other toolchains. Compliance check would complain,
        # for example, if you are using GCC.
        "LLVM_USE_LLD",
        "LOG_BACKEND_MOCK_OUTPUT_DEFAULT",  # Referenced in tests/subsys/logging/log_syst
        "LOG_BACKEND_MOCK_OUTPUT_SYST",  # Referenced in testcase.yaml of log_syst test
        "LSM6DSO_INT_PIN",
        "MCUBOOT_ACTION_HOOKS",  # Used in (sysbuild-based) test
        "MCUBOOT_CLEANUP_ARM_CORE",  # Used in (sysbuild-based) test
        "MCUBOOT_DOWNGRADE_PREVENTION",  # but symbols are defined in MCUboot itself.
        "MCUBOOT_LOG_LEVEL_DBG",
        "MCUBOOT_LOG_LEVEL_INF",
        "MCUBOOT_LOG_LEVEL_WRN",  # Used in example adjusting MCUboot config,
        "MCUBOOT_SERIAL",  # Used in (sysbuild-based) test/documentation
        "MCUMGR_GRP_EXAMPLE_OTHER_HOOK",  # Used in documentation
        # Used in modules/hal_nxp/mcux/mcux-sdk-ng/device/device.cmake.
        # It is a variable used by MCUX SDK CMake.
        "MCUX_HW_CORE",
        # Used in modules/hal_nxp/mcux/mcux-sdk-ng/device/device.cmake.
        # It is a variable used by MCUX SDK CMake.
        "MCUX_HW_DEVICE_CORE",
        # Used in modules/hal_nxp/mcux/mcux-sdk-ng/device/device.cmake.
        # It is a variable used by MCUX SDK CMake.
        "MCUX_HW_FPU_TYPE",
        "MISSING",
        "MODULES",
        "MODVERSIONS",  # Linux, in boards/xtensa/intel_adsp_cavs25/doc
        "MYFEATURE",
        "MY_DRIVER_0",
        "NORMAL_SLEEP",  # #defined by RV32M1 in ext/
        "NRF_WIFI_FW_BIN",  # Directly passed from CMakeLists.txt
        "OPT",
        "OPT_0",
        "PEDO_THS_MIN",
        "PSA_H",  # This is used in config-psa.h as guard for the header file
        "REG1",
        "REG2",
        "RIMAGE_SIGNING_SCHEMA",  # Optional module
        "SECURITY_LOADPIN",  # Linux, in boards/xtensa/intel_adsp_cavs25/doc
        "SEL",
        "SHIFT",
        "SINGLE_APPLICATION_SLOT",  # Used in sysbuild for MCUboot configuration
        "SINGLE_APPLICATION_SLOT_RAM_LOAD",  # Used in sysbuild for MCUboot configuration
        "SOC_NORDIC_BSP_PATH_OVERRIDE",  # Used in modules/hal_nordic/nrfx/CMakeLists.txt
        "SOC_SDKNG_UNSUPPORTED",  # Used in modules/hal_nxp/mcux/CMakeLists.txt
        "SOC_SERIES_",  # Used as regex in scripts/utils/board_v1_to_v2.py
        "SOC_WATCH",  # Issue 13749
        "SOME_BOOL",
        "SOME_INT",
        "SOME_OTHER_BOOL",
        "SOME_STRING",
        "SRAM2",  # Referenced in a comment in samples/application_development
        "STACK_SIZE",  # Used as an example in the Kconfig docs
        "STD_CPP",  # Referenced in CMake comment
        "TEST1",
        # Defined in modules/hal_nxp/mcux/mcux-sdk-ng/basic.cmake.
        # It is used by MCUX SDK cmake functions to add content
        # based on current toolchain.
        "TOOLCHAIN",
        # The symbol is defined in the toolchain
        # Kconfig which is sourced based on Zephyr
        # toolchain variant and therefore not visible
        # to compliance.
        "TOOLCHAIN_ARCMWDT_SUPPORTS_THREAD_LOCAL_STORAGE",
        "TYPE_BOOLEAN",
        "USB_CONSOLE",
        "USE_STDC_",
        "WHATEVER",
        "ZEPHYR_TRY_MASS_ERASE",  # MCUBoot setting described in sysbuild documentation
        "ZTEST_FAIL_TEST_",  # regex in tests/ztest/fail/CMakeLists.txt
        "ZVFS_OPEN_ADD_SIZE_",  # Used as an option matching prefix
        # zephyr-keep-sorted-stop
    }


class KconfigBasicCheck(KconfigCheck):
    """
    Checks if we are introducing any new warnings/errors with Kconfig,
    for example using undefined Kconfig variables.
    This runs the basic Kconfig test, which is checking only for undefined
    references inside the Kconfig tree.
    """

    name = "KconfigBasic"

    def check_no_undef_outside_kconfig(self, kconf):
        pass


class KconfigBasicNoModulesCheck(KconfigBasicCheck):
    """
    Checks if we are introducing any new warnings/errors with Kconfig when no
    modules are available. Catches symbols used in the main repository but
    defined only in a module.
    """

    name = "KconfigBasicNoModules"
    path_hint = "<zephyr-base>"
    EMPTY_FILE_CONTENTS = "# Empty\n"

    def get_modules(self, module_dirs_file, modules_file, sysbuild_modules_file, settings_file):
        with open(module_dirs_file, 'w') as fp_module_file:
            fp_module_file.write(self.EMPTY_FILE_CONTENTS)

        with open(modules_file, 'w') as fp_module_file:
            fp_module_file.write(self.EMPTY_FILE_CONTENTS)

        with open(sysbuild_modules_file, 'w') as fp_module_file:
            fp_module_file.write(self.EMPTY_FILE_CONTENTS)


class KconfigHWMv2Check(KconfigBasicCheck):
    """
    This runs the Kconfig test for board and SoC v2 scheme.
    This check ensures that all symbols inside the v2 scheme is also defined
    within the same tree.
    This ensures the board and SoC trees are fully self-contained and reusable.
    """

    name = "KconfigHWMv2"

    # Use dedicated Kconfig board / soc v2 scheme file.
    # This file sources only v2 scheme tree.
    FILENAME = os.path.join(os.path.dirname(__file__), "Kconfig.board.v2")


class SysbuildKconfigCheck(KconfigCheck):
    """
    Checks if we are introducing any new warnings/errors with sysbuild Kconfig,
    for example using undefined Kconfig variables.
    """

    name = "SysbuildKconfig"

    FILENAME = "share/sysbuild/Kconfig"
    CONFIG_ = "SB_CONFIG_"

    # A different allowlist is used for symbols prefixed with SB_CONFIG_ (omitted here).
    UNDEF_KCONFIG_ALLOWLIST = {
        # zephyr-keep-sorted-start re(^\s+")
        "FOO",
        "MY_IMAGE",  # Used in sysbuild documentation as example
        "OTHER_APP_IMAGE_NAME",  # Used in sysbuild documentation as example
        "OTHER_APP_IMAGE_PATH",  # Used in sysbuild documentation as example
        "SECOND_SAMPLE",  # Used in sysbuild documentation
        # zephyr-keep-sorted-stop
    }


class SysbuildKconfigBasicCheck(SysbuildKconfigCheck, KconfigBasicCheck):
    """
    Checks if we are introducing any new warnings/errors with sysbuild Kconfig,
    for example using undefined Kconfig variables.
    This runs the basic Kconfig test, which is checking only for undefined
    references inside the sysbuild Kconfig tree.
    """

    name = "SysbuildKconfigBasic"


class SysbuildKconfigBasicNoModulesCheck(SysbuildKconfigCheck, KconfigBasicNoModulesCheck):
    """
    Checks if we are introducing any new warnings/errors with sysbuild Kconfig
    when no modules are available. Catches symbols used in the main repository
    but defined only in a module.
    """

    name = "SysbuildKconfigBasicNoModules"
    path_hint = "<zephyr-base>"


class Nits(ComplianceTest):
    """
    Checks various nits in added/modified files. Doesn't check stuff that's
    already covered by e.g. checkpatch.pl and pylint.
    """

    name = "Nits"
    doc = zephyr_doc_detail_builder("/contribute/guidelines.html#coding-style")

    def run(self):
        # Loop through added/modified files
        for fname in get_files(filter="d"):
            if "Kconfig" in fname:
                self.check_kconfig_header(fname)
                self.check_redundant_zephyr_source(fname)

            if fname.startswith("dts/bindings/"):
                self.check_redundant_document_separator(fname)

            if (
                fname.endswith(
                    (
                        ".c",
                        ".conf",
                        ".cpp",
                        ".dts",
                        ".overlay",
                        ".h",
                        ".ld",
                        ".py",
                        ".rst",
                        ".txt",
                        ".yaml",
                        ".yml",
                    )
                )
                or "Kconfig" in fname
                or "defconfig" in fname
                or fname == "README"
            ):
                self.check_source_file(fname)

    def check_kconfig_header(self, fname):
        # Checks for a spammy copy-pasted header format

        with open(GIT_TOP / fname, encoding="utf-8") as f:
            contents = f.read()

        # 'Kconfig - yada yada' has a copy-pasted redundant filename at the
        # top. This probably means all of the header was copy-pasted.
        if re.match(r"\s*#\s*(K|k)config[\w.-]*\s*-", contents):
            self.failure(f"""
Please use this format for the header in '{fname}' (see
https://docs.zephyrproject.org/latest/build/kconfig/tips.html#header-comments-and-other-nits):

    # <Overview of symbols defined in the file, preferably in plain English>
    (Blank line)
    # Copyright (c) 2019 ...
    # SPDX-License-Identifier: <License>
    (Blank line)
    (Kconfig definitions)

Skip the "Kconfig - " part of the first line, since it's clear that the comment
is about Kconfig from context. The "# Kconfig - " is what triggers this
failure.
""")

    def check_redundant_zephyr_source(self, fname):
        # Checks for 'source "$(ZEPHYR_BASE)/Kconfig[.zephyr]"', which can be
        # be simplified to 'source "Kconfig[.zephyr]"'

        with open(GIT_TOP / fname, encoding="utf-8") as f:
            # Look for e.g. rsource as well, for completeness
            match = re.search(
                r'^\s*(?:o|r|or)?source\s*"\$\(?ZEPHYR_BASE\)?/(Kconfig(?:\.zephyr)?)"',
                f.read(),
                re.MULTILINE,
            )

            if match:
                self.failure(
                    """
Redundant 'source "$(ZEPHYR_BASE)/{0}" in '{1}'. Just do 'source "{0}"'
instead. The $srctree environment variable already points to the Zephyr root,
and all 'source's are relative to it.""".format(match.group(1), fname)
                )

    def check_redundant_document_separator(self, fname):
        # Looks for redundant '...' document separators in bindings

        with open(GIT_TOP / fname, encoding="utf-8") as f:
            if re.search(r"^\.\.\.", f.read(), re.MULTILINE):
                self.failure(f"""\
Redundant '...' document separator in {fname}. Binding YAML files are never
concatenated together, so no document separators are needed.""")

    def check_source_file(self, fname):
        # Generic nits related to various source files

        with open(GIT_TOP / fname, encoding="utf-8") as f:
            contents = f.read()

        if not contents.endswith("\n"):
            self.failure(f"Missing newline at end of '{fname}'. Check your text editor settings.")

        if contents.startswith("\n"):
            self.failure(f"Please remove blank lines at start of '{fname}'")

        if contents.endswith("\n\n"):
            self.failure(f"Please remove blank lines at end of '{fname}'")


class GitDiffCheck(ComplianceTest):
    """
    Checks for conflict markers or whitespace errors with git diff --check
    """

    name = "GitDiffCheck"
    doc = "Git conflict markers and whitespace errors are not allowed in added changes"

    def run(self):
        offending_lines = []
        # Use regex to filter out unnecessay output
        # Reason: `--check` is mutually exclusive with `--name-only` and `-s`
        p = re.compile(r"\S+\: .*\.")

        for shaidx in get_shas(COMMIT_RANGE):
            # Ignore non-zero return status code
            # Reason: `git diff --check` sets the return code to the number of offending lines
            cmd = ["diff", f"{shaidx}^!", "--check", "--", ":!*.diff", ":!*.patch"]
            diff = git(*cmd, ignore_non_zero=True)

            lines = p.findall(diff)
            lines = map(lambda x: f"{shaidx}: {x}", lines)
            offending_lines.extend(lines)

        if len(offending_lines) > 0:
            self.failure("\n".join(offending_lines))


class LicenseAndCopyrightCheck(ComplianceTest):
    """
    Verify that every file touched by the patch set has correct SPDX headers and uses allowed
    license.
    """

    name = "LicenseAndCopyrightCheck"
    doc = "Check SPDX headers and copyright lines with the reuse Python API."

    def _report_violations(
        self,
        paths: Iterable[Path],
        title: str,
        severity: str,
        desc: str | None = None,
    ) -> None:
        for p in paths:
            rel_path = os.path.relpath(str(p), GIT_TOP)
            self.fmtd_failure(severity, title, rel_path, desc=desc or "", line=1)

    def run(self) -> None:
        changed_files = get_files(filter="d")
        if not changed_files:
            return

        # Only scan text files for now, in the future we may want to leverage REUSE standard's
        # ability to also associate license/copyright info with binary files.
        for file in changed_files:
            full_path = GIT_TOP / file
            mime_type = magic.from_file(os.fspath(full_path), mime=True)
            if not mime_type.startswith("text/"):
                changed_files.remove(file)

        project = Project.from_directory(GIT_TOP)
        report = ProjectSubsetReport.generate(project, changed_files, multiprocessing=False)

        self._report_violations(
            report.files_without_licenses,
            "License missing",
            "warning",
            "File has no SPDX-License-Identifier header, consider adding one.",
        )

        self._report_violations(
            report.files_without_copyright,
            "Copyright missing",
            "warning",
            "File has no SPDX-FileCopyrightText header, consider adding one.",
        )

        for lic_id, paths in getattr(report, "missing_licenses", {}).items():
            self._report_violations(
                paths,
                "License may not be allowed",
                "warning",
                (
                    f"License file for '{lic_id}' not found in /LICENSES. Please check "
                    "https://docs.zephyrproject.org/latest/contribute/guidelines.html#components-using-other-licenses."
                ),
            )


class GitLint(ComplianceTest):
    """
    Runs gitlint on the commits and finds issues with style and syntax

    """

    name = "Gitlint"
    doc = zephyr_doc_detail_builder("/contribute/guidelines.html#commit-guidelines")

    def run(self):
        # By default gitlint looks for .gitlint configuration only in
        # the current directory
        try:
            subprocess.run(
                'gitlint --commits ' + COMMIT_RANGE,
                check=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                shell=True,
                cwd=GIT_TOP,
            )

        except subprocess.CalledProcessError as ex:
            self.failure(ex.output.decode("utf-8"))


class PyLint(ComplianceTest):
    """
    Runs pylint on all .py files, with a limited set of checks enabled. The
    configuration is in the pylintrc file.
    """

    name = "Pylint"
    doc = "See https://www.pylint.org/ for more details"

    def run(self):
        # Path to pylint configuration file
        pylintrc = os.path.abspath(os.path.join(os.path.dirname(__file__), "pylintrc"))

        # Path to additional pylint check scripts
        check_script_dir = os.path.abspath(
            os.path.join(os.path.dirname(__file__), "../pylint/checkers")
        )

        # List of files added/modified by the commit(s).
        files = get_files(filter="d")

        # Filter out everything but Python files. Keep filenames
        # relative (to GIT_TOP) to stay farther from any command line
        # limit.
        py_files = filter_py(GIT_TOP, files)
        if not py_files:
            return

        python_environment = os.environ.copy()
        if "PYTHONPATH" in python_environment:
            python_environment["PYTHONPATH"] = (
                check_script_dir + ":" + python_environment["PYTHONPATH"]
            )
        else:
            python_environment["PYTHONPATH"] = check_script_dir

        pylintcmd = [
            "pylint",
            "--output-format=json2",
            "--rcfile=" + pylintrc,
            "--load-plugins=argparse-checker",
        ] + py_files
        logger.info(cmd2str(pylintcmd))
        try:
            subprocess.run(
                pylintcmd,
                check=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=GIT_TOP,
                env=python_environment,
            )
        except subprocess.CalledProcessError as ex:
            output = ex.output.decode("utf-8")
            messages = json.loads(output)['messages']
            for m in messages:
                severity = 'unknown'
                if m['messageId'][0] in ('F', 'E'):
                    severity = 'error'
                elif m['messageId'][0] in ('W', 'C', 'R', 'I'):
                    severity = 'warning'
                self.fmtd_failure(
                    severity,
                    m['messageId'],
                    m['path'],
                    m['line'],
                    col=str(m['column']),
                    desc=m['message'] + f" ({m['symbol']})",
                )

            if len(messages) == 0:
                # If there are no specific messages add the whole output as a failure
                self.failure(output)


def filter_py(root, fnames):
    # PyLint check helper. Returns all Python script filenames among the
    # filenames in 'fnames', relative to directory 'root'.
    #
    # Uses the python-magic library, so that we can detect Python
    # files that don't end in .py as well. python-magic is a frontend
    # to libmagic, which is also used by 'file'.
    return [
        fname
        for fname in fnames
        if (
            fname.endswith(".py")
            or magic.from_file(os.path.join(root, fname), mime=True) == "text/x-python"
        )
    ]


class CMakeStyle(ComplianceTest):
    """
    Checks cmake style added/modified files
    """

    name = "CMakeStyle"
    doc = zephyr_doc_detail_builder("/contribute/style/cmake.html")

    def run(self):
        # Loop through added/modified files
        for fname in get_files(filter="d"):
            if fname.endswith(".cmake") or fname.endswith("CMakeLists.txt"):
                self.check_style(fname)

    def check_style(self, fname):
        SPACE_BEFORE_OPEN_BRACKETS_CHECK = re.compile(r"^\s*if\s+\(")
        TAB_INDENTATION_CHECK = re.compile(r"^\t+")

        with open(fname, encoding="utf-8") as f:
            for line_num, line in enumerate(f.readlines(), start=1):
                if TAB_INDENTATION_CHECK.match(line):
                    self.fmtd_failure(
                        "error",
                        "CMakeStyle",
                        fname,
                        line_num,
                        "Use spaces instead of tabs for indentation",
                    )

                if SPACE_BEFORE_OPEN_BRACKETS_CHECK.match(line):
                    self.fmtd_failure(
                        "error",
                        "CMakeStyle",
                        fname,
                        line_num,
                        "Remove space before '(' in if() statements",
                    )


class Identity(ComplianceTest):
    """
    Checks if Emails of author and signed-off messages are consistent.
    """

    name = "Identity"
    doc = zephyr_doc_detail_builder("/contribute/guidelines.html#commit-guidelines")

    def run(self):
        for shaidx in get_shas(COMMIT_RANGE):
            commit_info = git('show', '-s', '--format=%an%n%ae%n%b', shaidx).split('\n', 2)

            failures = []

            if len(commit_info) == 2:
                failures.append(f'{shaidx}: Empty commit message body')
                auth_name, auth_email = commit_info
                body = ''
            elif len(commit_info) == 3:
                auth_name, auth_email, body = commit_info
            else:
                self.failure(f'Unable to parse commit message for {shaidx}')
                continue

            if auth_email.endswith("@users.noreply.github.com"):
                failures.append(
                    f"{shaidx}: author email ({auth_email}) must "
                    "be a real email and cannot end in "
                    "@users.noreply.github.com"
                )

            # Returns an array of everything to the right of ':' on each signoff line
            signoff_lines = re.findall(r"signed-off-by:\s(.*)", body, re.IGNORECASE)
            if len(signoff_lines) == 0:
                failures.append(f'{shaidx}: Missing signed-off-by line')
            else:
                # Validate all signoff lines' syntax while also searching for commit author
                found_author_signoff = False
                for signoff in signoff_lines:
                    match = re.search(r"(.+) <(.+)>", signoff)

                    if not match:
                        failures.append(
                            f"{shaidx}: Signed-off-by line ({signoff}) "
                            "does not follow the syntax: First "
                            "Last <email>."
                        )
                    elif (auth_name, auth_email) == match.groups():
                        found_author_signoff = True

                if not found_author_signoff:
                    failures.append(
                        f"{shaidx}: author name ({auth_name}) and email ({auth_email}) "
                        "needs to match one of the signed-off-by entries."
                    )

            if failures:
                self.failure('\n'.join(failures))


class BinaryFiles(ComplianceTest):
    """
    Check that the diff contains no binary files.
    """

    name = "BinaryFiles"
    doc = "No binary files allowed."

    def run(self):
        BINARY_ALLOW_PATHS = ("doc/", "boards/", "samples/")
        # svg files are always detected as binary, see .gitattributes
        BINARY_ALLOW_EXT = (".jpg", ".jpeg", ".png", ".svg", ".webp")

        for stat in git("diff", "--numstat", "--diff-filter=A", COMMIT_RANGE).splitlines():
            added, deleted, fname = stat.split("\t")
            if added == "-" and deleted == "-":
                if fname.startswith(BINARY_ALLOW_PATHS) and fname.endswith(BINARY_ALLOW_EXT):
                    continue
                self.failure(f"Binary file not allowed: {fname}")


class ImageSize(ComplianceTest):
    """
    Check that any added image is limited in size.
    """

    name = "ImageSize"
    doc = "Check the size of image files."

    def run(self):
        SIZE_LIMIT = 250 << 10
        BOARD_SIZE_LIMIT = 100 << 10

        for file in get_files(filter="d"):
            full_path = GIT_TOP / file
            mime_type = magic.from_file(os.fspath(full_path), mime=True)

            if not mime_type.startswith("image/"):
                continue

            size = os.path.getsize(full_path)

            limit = SIZE_LIMIT
            if file.startswith("boards/"):
                limit = BOARD_SIZE_LIMIT

            if size > limit:
                self.failure(
                    f"Image file too large: {file} reduce size to less than {limit >> 10}kB"
                )


class MaintainersFormat(ComplianceTest):
    """
    Check that MAINTAINERS file parses correctly.
    """

    name = "MaintainersFormat"
    doc = "Check that MAINTAINERS file parses correctly."

    def run(self):
        MAINTAINERS_FILES = ["MAINTAINERS.yml", "MAINTAINERS.yaml"]

        for file in MAINTAINERS_FILES:
            if not os.path.exists(file):
                continue

            try:
                Maintainers(file)
            except MaintainersError as ex:
                self.failure(f"Error parsing {file}: {ex}")


class ModulesMaintainers(ComplianceTest):
    """
    Check that all modules have a MAINTAINERS entry.
    """

    name = "ModulesMaintainers"
    doc = "Check that all modules have a MAINTAINERS entry."

    def run(self):
        MAINTAINERS_FILES = ["MAINTAINERS.yml", "MAINTAINERS.yaml"]

        manifest = Manifest.from_file()

        maintainers_file = None
        for file in MAINTAINERS_FILES:
            if os.path.exists(file):
                maintainers_file = file
                break
        if not maintainers_file:
            return

        maintainers = Maintainers(maintainers_file)

        for project in manifest.get_projects([]):
            if not manifest.is_active(project):
                continue

            if isinstance(project, ManifestProject):
                continue

            area = f"West project: {project.name}"
            if area not in maintainers.areas:
                self.failure(f"Missing {maintainers_file} entry for: \"{area}\"")


class ZephyrModuleFile(ComplianceTest):
    """
    Check that no zephyr/module.yml file has been added to the Zephyr repository
    """

    name = "ZephyrModuleFile"
    doc = "Check that no zephyr/module.yml file has been added to the Zephyr repository."

    def run(self):
        module_files = [
            ZEPHYR_BASE / 'zephyr' / 'module.yml',
            ZEPHYR_BASE / 'zephyr' / 'module.yaml',
        ]

        for file in module_files:
            if os.path.exists(file):
                self.failure("A zephyr module file has been added to the Zephyr repository")
                break


class YAMLLint(ComplianceTest):
    """
    YAMLLint
    """

    name = "YAMLLint"
    doc = "Check YAML files with YAMLLint."

    def run(self):
        config_file = ZEPHYR_BASE / ".yamllint"

        for file in get_files(filter="d"):
            if Path(file).suffix not in ['.yaml', '.yml']:
                continue

            yaml_config = config.YamlLintConfig(file=config_file)

            if file.startswith(".github/"):
                # Tweak few rules for workflow files.
                yaml_config.rules["line-length"] = False
                yaml_config.rules["truthy"]["allowed-values"].extend(['on', 'off'])
            elif file == ".codecov.yml":
                yaml_config.rules["truthy"]["allowed-values"].extend(['yes', 'no'])

            with open(file) as fp:
                for p in linter.run(fp, yaml_config):
                    self.fmtd_failure(
                        'warning', f'YAMLLint ({p.rule})', file, p.line, col=p.column, desc=p.desc
                    )


class SphinxLint(ComplianceTest):
    """
    SphinxLint
    """

    name = "SphinxLint"
    doc = "Check Sphinx/reStructuredText files with sphinx-lint."

    # Checkers added/removed to sphinx-lint's default set
    DISABLE_CHECKERS = [
        "horizontal-tab",
        "missing-space-before-default-role",
        "trailing-whitespace",
    ]
    ENABLE_CHECKERS = ["default-role"]

    def run(self):
        for file in get_files():
            if not file.endswith(".rst"):
                continue

            try:
                # sphinx-lint does not expose a public API so interaction is done via CLI
                disable_checkers = ','.join(self.DISABLE_CHECKERS)
                enable_checkers = ','.join(self.ENABLE_CHECKERS)
                subprocess.run(
                    f"sphinx-lint -d {disable_checkers} -e {enable_checkers} {file}",
                    check=True,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    shell=True,
                    cwd=GIT_TOP,
                )

            except subprocess.CalledProcessError as ex:
                for line in ex.output.decode("utf-8").splitlines():
                    match = re.match(r"^(.*):(\d+): (.*)$", line)

                    if match:
                        self.fmtd_failure(
                            "error",
                            "SphinxLint",
                            match.group(1),
                            int(match.group(2)),
                            desc=match.group(3),
                        )


class KeepSorted(ComplianceTest):
    """
    Check for blocks of code or config that should be kept sorted.
    """

    name = "KeepSorted"
    doc = "Check for blocks of code or config that should be kept sorted."

    MARKER = "zephyr-keep-sorted"

    def block_check_sorted(self, block_data, *, regex, strip, fold):
        def _test_indent(txt: str):
            return txt.startswith((" ", "\t"))

        if regex is None:
            block_data = textwrap.dedent(block_data)

        lines = block_data.splitlines()
        last = ''

        for idx, line in enumerate(lines):
            if not line.strip():
                # Ignore blank lines
                continue

            if strip is not None:
                line = line.strip(strip)

            if regex:
                # check for regex
                if not re.match(regex, line):
                    continue
            else:
                if _test_indent(line):
                    continue

                if fold:
                    # Fold back indented lines after the current one
                    for cont in takewhile(_test_indent, lines[idx + 1 :]):
                        line += cont.strip()

            if line < last:
                return idx

            last = line

        return -1

    def check_file(self, file, fp):
        mime_type = magic.from_file(os.fspath(file), mime=True)

        if not mime_type.startswith("text/"):
            return

        block_data = ""
        in_block = False

        start_marker = f"{self.MARKER}-start"
        stop_marker = f"{self.MARKER}-stop"
        regex_marker = r"re\(([^)]+)\)"
        strip_marker = r"strip\(([^)]+)\)"
        nofold_marker = "nofold"
        start_line = 0
        regex = None
        strip = None
        fold = True

        for line_num, line in enumerate(fp.readlines(), start=1):
            if start_marker in line:
                if in_block:
                    desc = f"nested {start_marker}"
                    self.fmtd_failure("error", "KeepSorted", file, line_num, desc=desc)
                in_block = True
                block_data = ""
                start_line = line_num + 1

                # Test for a regex block
                match = re.search(regex_marker, line)
                regex = match.group(1) if match else None

                match = re.search(strip_marker, line)
                strip = match.group(1) if match else None

                fold = nofold_marker not in line
            elif stop_marker in line:
                if not in_block:
                    desc = f"{stop_marker} without {start_marker}"
                    self.fmtd_failure("error", "KeepSorted", file, line_num, desc=desc)
                in_block = False

                idx = self.block_check_sorted(block_data, regex=regex, strip=strip, fold=fold)
                if idx >= 0:
                    desc = f"sorted block has out-of-order line at {start_line + idx}"
                    self.fmtd_failure("error", "KeepSorted", file, line_num, desc=desc)
            elif in_block:
                block_data += line

        if in_block:
            self.failure(f"unterminated {start_marker} in {file}")

    def run(self):
        for file in get_files(filter="d"):
            with open(file) as fp:
                self.check_file(file, fp)


class Ruff(ComplianceTest):
    """
    Ruff
    """

    name = "Ruff"
    doc = "Check python files with ruff."

    def run(self):
        try:
            subprocess.run(
                "ruff check --output-format=json",
                check=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                shell=True,
                cwd=GIT_TOP,
            )
        except subprocess.CalledProcessError as ex:
            output = ex.output.decode("utf-8")
            messages = json.loads(output)
            for m in messages:
                self.fmtd_failure(
                    "error",
                    f'Python lint error ({m.get("code")}) see {m.get("url")}',
                    m.get("filename"),
                    line=m.get("location", {}).get("row"),
                    col=m.get("location", {}).get("column"),
                    end_line=m.get("end_location", {}).get("row"),
                    end_col=m.get("end_location", {}).get("column"),
                    desc=m.get("message"),
                )

        for file in get_files(filter="d"):
            if not file.endswith((".py", ".pyi")):
                continue

            try:
                subprocess.run(
                    f"ruff format --force-exclude --diff {file}",
                    check=True,
                    shell=True,
                    cwd=GIT_TOP,
                )
            except subprocess.CalledProcessError:
                desc = f"Run 'ruff format {file}'"
                self.fmtd_failure("error", "Python format error", file, desc=desc)


class PythonCompatCheck(ComplianceTest):
    """
    Python Compatibility Check
    """

    name = "PythonCompat"
    doc = "Check that Python files are compatible with Zephyr minimum supported Python version."

    MAX_VERSION = (3, 10)
    MAX_VERSION_STR = f"{MAX_VERSION[0]}.{MAX_VERSION[1]}"

    def run(self):
        py_files = [f for f in get_files(filter="d") if f.endswith(".py")]
        if not py_files:
            return
        cmd = [
            "vermin",
            "-f",
            "parsable",
            "--violations",
            f"-t={self.MAX_VERSION_STR}",
            "--no-make-paths-absolute",
        ] + py_files
        try:
            result = subprocess.run(cmd, check=False, capture_output=True, cwd=GIT_TOP)
        except Exception as ex:
            self.error(f"Failed to run vermin: {ex}")
        output = result.stdout.decode("utf-8")
        failed = False
        for line in output.splitlines():
            parts = line.split(":")
            if len(parts) < 6:
                continue
            filename, line_number, column, _, py3ver, feature = parts[:6]
            if not line_number:
                # Ignore all file-level messages
                continue

            desc = None
            if py3ver.startswith('!'):
                desc = f"{feature} is known to be incompatible with Python 3."
            elif py3ver.startswith('~'):
                # "no known reason it won't work", just skip
                continue
            else:
                major, minor = map(int, py3ver.split(".")[:2])
                if (major, minor) > self.MAX_VERSION:
                    desc = (
                        f"{feature} requires Python {major}.{minor}, which is higher than "
                        f"Zephyr's minimum supported Python version ({self.MAX_VERSION_STR})."
                    )

            if desc is not None:
                self.fmtd_failure(
                    "error",
                    "PythonCompat",
                    filename,
                    line=int(line_number),
                    col=int(column) if column else None,
                    desc=desc,
                )
                failed = True
        if failed:
            self.failure(
                "Some Python files use features that are not compatible with Python "
                f"{self.MAX_VERSION_STR}."
            )


class TextEncoding(ComplianceTest):
    """
    Check that any text file is encoded in ascii or utf-8.
    """

    name = "TextEncoding"
    doc = "Check the encoding of text files."

    ALLOWED_CHARSETS = ["us-ascii", "utf-8"]

    def run(self):
        m = magic.Magic(mime=True, mime_encoding=True)

        for file in get_files(filter="d"):
            full_path = GIT_TOP / file
            mime_type = m.from_file(os.fspath(full_path))

            if not mime_type.startswith("text/"):
                continue

            # format is "text/<type>; charset=<charset>"
            if mime_type.rsplit('=')[-1] not in self.ALLOWED_CHARSETS:
                desc = f"Text file with unsupported encoding: {file} has mime type {mime_type}"
                self.fmtd_failure("error", "TextEncoding", file, desc=desc)


def init_logs(cli_arg):
    # Initializes logging

    global logger

    level = os.environ.get('LOG_LEVEL', "WARN")

    console = logging.StreamHandler()
    console.setFormatter(logging.Formatter('%(levelname)-8s: %(message)s'))

    logger = logging.getLogger('')
    logger.addHandler(console)
    logger.setLevel(cli_arg or level)

    logger.info("Log init completed, level=%s", logging.getLevelName(logger.getEffectiveLevel()))


def inheritors(klass):
    subclasses = set()
    work = [klass]
    while work:
        parent = work.pop()
        for child in parent.__subclasses__():
            if child not in subclasses:
                subclasses.add(child)
                work.append(child)
    return subclasses


def annotate(res):
    """
    https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#about-workflow-commands
    """
    msg = res.message.replace('%', '%25').replace('\n', '%0A').replace('\r', '%0D')
    notice = (
        f'::{res.severity} file={res.file}'
        + (f',line={res.line}' if res.line else '')
        + (f',col={res.col}' if res.col else '')
        + (f',endLine={res.end_line}' if res.end_line else '')
        + (f',endColumn={res.end_col}' if res.end_col else '')
        + f',title={res.title}::{msg}'
    )
    print(notice)


def resolve_path_hint(hint):
    if hint == "<zephyr-base>":
        return ZEPHYR_BASE
    elif hint == "<git-top>":
        return GIT_TOP
    else:
        return hint


def parse_args(argv):
    default_range = 'HEAD~1..HEAD'
    parser = argparse.ArgumentParser(
        description="Check for coding style and documentation warnings.", allow_abbrev=False
    )
    parser.add_argument(
        '-c',
        '--commits',
        default=default_range,
        help=f'''Commit range in the form: a..[b], default is
                        {default_range}''',
    )
    parser.add_argument(
        '-o',
        '--output',
        default="compliance.xml",
        help='''Name of outfile in JUnit format,
                        default is ./compliance.xml''',
    )
    parser.add_argument(
        '-n',
        '--no-case-output',
        action="store_true",
        help="Do not store the individual test case output.",
    )
    parser.add_argument('-l', '--list', action="store_true", help="List all checks and exit")
    parser.add_argument(
        "-v",
        "--loglevel",
        choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
        help="python logging level",
    )
    parser.add_argument(
        '-m',
        '--module',
        action="append",
        default=[],
        help="Checks to run. All checks by default. (case insensitive)",
    )
    parser.add_argument(
        '-e',
        '--exclude-module',
        action="append",
        default=[],
        help="Do not run the specified checks (case insensitive)",
    )
    parser.add_argument(
        '-j',
        '--previous-run',
        default=None,
        help='''Pre-load JUnit results in XML format
                        from a previous run and combine with new results.''',
    )
    parser.add_argument(
        '--annotate', action="store_true", help="Print GitHub Actions-compatible annotations."
    )

    return parser.parse_args(argv)


def _main(args):
    # The "real" main(), which is wrapped to catch exceptions and report them
    # to GitHub. Returns the number of test failures.

    # The absolute path of the top-level git directory. Initialize it here so
    # that issues running Git can be reported to GitHub.
    global GIT_TOP
    GIT_TOP = Path(git("rev-parse", "--show-toplevel"))

    # The commit range passed in --commit, e.g. "HEAD~3"
    global COMMIT_RANGE
    COMMIT_RANGE = args.commits

    init_logs(args.loglevel)

    logger.info(f'Running tests on commit range {COMMIT_RANGE}')

    if args.list:
        for testcase in sorted(inheritors(ComplianceTest), key=lambda x: x.name):
            print(testcase.name)
        return 0

    # Load saved test results from an earlier run, if requested
    if args.previous_run:
        if not os.path.exists(args.previous_run):
            # This probably means that an earlier pass had an internal error
            # (the script is currently run multiple times by the ci-pipelines
            # repo). Since that earlier pass might've posted an error to
            # GitHub, avoid generating a GitHub comment here, by avoiding
            # sys.exit() (which gets caught in main()).
            print(f"error: '{args.previous_run}' not found", file=sys.stderr)
            return 1

        logging.info(f"Loading previous results from {args.previous_run}")
        for loaded_suite in JUnitXml.fromfile(args.previous_run):
            suite = loaded_suite
            break
    else:
        suite = TestSuite("Compliance")

    included = list(map(lambda x: x.lower(), args.module))
    excluded = list(map(lambda x: x.lower(), args.exclude_module))

    for testcase in inheritors(ComplianceTest):
        # "Modules" and "testcases" are the same thing. Better flags would have
        # been --tests and --exclude-tests or the like, but it's awkward to
        # change now.

        if included and testcase.name.lower() not in included:
            continue

        if testcase.name.lower() in excluded:
            print("Skipping " + testcase.name)
            continue

        test = testcase()
        try:
            print(f"Running {test.name:30} tests in {resolve_path_hint(test.path_hint)} ...")
            test.run()
        except EndTest:
            pass
        except BaseException:
            test.failure(f"An exception occurred in {test.name}:\n{traceback.format_exc()}")

        # Annotate if required
        if args.annotate:
            for res in test.fmtd_failures:
                annotate(res)

        suite.add_testcase(test.case)

    if args.output:
        xml = JUnitXml()
        xml.add_testsuite(suite)
        xml.update_statistics()
        xml.write(args.output, pretty=True)

    failed_cases = []
    warning_cases = []
    name2doc = {testcase.name: testcase.doc for testcase in inheritors(ComplianceTest)}

    for case in suite:
        if case.result:
            if case.is_skipped:
                logging.warning(f"Skipped {case.name}")
            else:
                if any(res.type in ('error', 'failure') for res in case.result):
                    failed_cases.append(case)
                else:
                    warning_cases.append(case)
        else:
            # Some checks can produce no .result
            logging.info(f"No JUnit result for {case.name}")

    n_fails = len(failed_cases)
    n_warnings = len(warning_cases)

    if n_fails or n_warnings:
        if n_fails:
            print(f"{n_fails} check(s) failed")
        if n_warnings:
            print(f"{n_warnings} check(s) with warnings only")

        for case in failed_cases + warning_cases:
            for res in case.result:
                errmsg = res.text.strip()
                if res.type in ('error', 'failure'):
                    logging.error(f"Test {case.name} failed: \n{errmsg}")
                else:
                    logging.warning(f"Test {case.name} warning: \n{errmsg}")

            if args.no_case_output:
                continue
            with open(f"{case.name}.txt", "w") as f:
                docs = name2doc.get(case.name)
                f.write(f"{docs}\n")
                for res in case.result:
                    errmsg = res.text.strip()
                    f.write(f'\n {errmsg}')

    if args.output:
        print(f"\nComplete results in {args.output}")
    return n_fails


def main(argv=None):
    args = parse_args(argv)

    try:
        n_fails = _main(args)
    except BaseException:
        # Catch BaseException instead of Exception to include stuff like
        # SystemExit (raised by sys.exit())
        print(f"Python exception in `{__file__}`:\n\n```\n{traceback.format_exc()}\n```")

        raise

    sys.exit(n_fails)


def cmd2str(cmd):
    # Formats the command-line arguments in the iterable 'cmd' into a string,
    # for error messages and the like

    return " ".join(shlex.quote(word) for word in cmd)


def err(msg):
    cmd = sys.argv[0]  # Empty if missing
    if cmd:
        cmd += ": "
    sys.exit(f"{cmd} error: {msg}")


if __name__ == "__main__":
    main(sys.argv[1:])
